Skip to content

Conversation

@kligarski
Copy link
Contributor

@kligarski kligarski commented Oct 7, 2025

Description

Adds support for botttomAccessory in Bottom Tabs starting from iOS 26.

Synchronization with ShadowTree

When bottom accessory transitions between regular and inline environments (when tab bar is minimized), we need to update the position and size of the bottom accessory. Our approach is different for RN < 0.82 and RN >= 0.82.

Possible approaches

We considered following approaches:

  1. asynchronous state updates & events
  • this would result in frame changing right after animation start to the final size
  • using double-rendering approach (explained further in PR description) wouldn't really improve the situation
  1. (a)synchronous state updates & asynchronous events + DisplayLink
  • this allows us to track the native animation of the bottom accessory and match frame size
  • unfortunately, as DisplayLink allows us to read presentation layer frame, we will always be 1 frame delayed
  • with asynchronous updates, this delay increases to multiple frames; with synchronous updates this would be exactly 1 frame (but this doesn't mean it looks better!)
  1. synchronous state updates & asynchronous events
  • synchronous state updates allow us to rely on CoreAnimations framework that animates views natively (details explained further in PR description)
  • unfortunately, this mechanism requires all changes to be performed synchronously - state update (size change) is handled synchronously thanks to immediate mode for state update introduced in RN 0.82 but "synchronous" event dispatch in RN 0.82 isn't exactly synchronous (update is performed on next loop beat, this is not enough for CA)
  • due to this limitation, any changes in reaction to environment change are buggy (see Mounting/unmounting views during transition section)
  1. synchronous state updates & double-rendering
  • to mitigate problems described above, we render the component twice - first for regular environment, second for inline environment
  • we change which component is visible on environment change -> we can do this fully synchronously in native code
  • CA animates this change with cross-fade animation
  • this results seems to be a good compromise but requires React state synchronization between components, e.g. via context

For now, we decided to use:

  • Paper: approach 2 (asynchronous)
  • Fabric RN < 0.82: approach 2 (asynchronous)
  • Fabric RN >= 0.82: approach 4

Those solutions are described in more detail below:

Legacy architecture & New architecture prior to [email protected]

In versions prior to RN 0.82, we need use DisplayLink and presentation layer frames to get intermediate frames during the transition. This approach however has a major drawback - we are always at least one frame behind the current state as we're observing what is currently presented. When the difference in size/origin between frames is significant, you can see the content "jumping". In the case of bottom accessory, this is especially visible when using non-translucent background and transitioning from inline to regular environment (pay attention to the right edge of the accessory).

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.08.31.39.mov

Introduction of synchronous state updates in RN 0.82 (more details below) does not improve the situation when using this approach as we are still going to be at least one frame behind the animation.

[email protected] and higher

Thanks to introduction of synchronous state updates in RN 0.82, we can rely fully on native mechanisms for handling the transition. Bottom accessory only receives the final frame of the transition and thanks to synchronous state updates, we can immediately recalculate the layout in the Shadow Tree and update the Host Tree. This allows Core Animation framework to make the transition smooth. Details of how we think this works are available here.

Unfortunately, when using react-native, the situation is a little bit more complicated.

Text

Text component behaves differently to the native platform. During the transition, it immediately adapts to the final frame size and then it is stretched. In bare UIKit app, the text adapt to new frame size at the end of the transition.

react-native UIKit
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.16.33.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.22.22.mov

This requires more investigation and potentially changes in react-native.

Borders

CoreAnimation does not support non-uniform borders so react-native handles them in a custom way that does not seem to be compatible with the transition mechanism.

non-uniform borders uniform borders + CA enabled
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.28.39.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.32.17.mov

This requires more investigation and potentially changes in react-native.

Images

Similar problem (in a way it looks, not the exact mechanism of the bug) happens when using images e.g. with width: 100%.

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.11.08.09.mov

This requires more investigation and potentially changes in react-native.

Mounting/unmounting views during transition

While state updates are performed synchronously, any changes to React Element Tree in reaction to environment change are handled asynchronously. We think that this is why the transition handled by CoreAnimation breaks when trying to mount/unmount components on environment change.

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.08.16.12.mov

Here, we try to remove the note icon. Unfortunately, the rest of the layout does not adapt. You can also observe the text stretching as mentioned 2 sections above.

In order to mitigate this issue, we use double-rendering approach, which has been described in Possible approaches section.

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-11-10.at.13.50.25.mov

Changes

  • add BottomTabsAccessory JS component and use it in BottomTabs,
  • add BottomTabsAccessoryComponentView, BottomTabsAccessoryEventEmitter, BottomTabsAccessoryComponentViewManager,
  • add BottomAccessoryHelper to handle size and environment changes,
  • add BottomTabsAccessoryShadowStateProxy to synchronize state between Host and ShadowTree,
  • adapt BottomTabsHost to accept 2 types of children (Screen and Accessory),
  • add test screen.

Test code and steps to reproduce

Run Test3288.

Checklist

@kligarski kligarski marked this pull request as ready for review October 13, 2025 09:10
Copy link
Contributor

@t0maboro t0maboro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't finished yet, flushing the 1st part

@kligarski kligarski requested review from kkafar and t0maboro November 6, 2025 08:06
Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really good job. The PR is in really good shape & code is of high quality - kudos.

I've got few remarks, refactor requests & questions. Please answer them.

@kligarski kligarski requested a review from kkafar November 10, 2025 12:53
Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks nice. I've added few more suggestions & answered some questions.

#if BOTTOM_ACCESSORY_AVAILABLE

#import <React/RCTAssert.h>
#include <cxxreact/ReactNativeVersion.h>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why all of a sudden include here, instead of import? Also the check for cplusplus is missing? Or am I missing something? Maybe its included somewhere transitively?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing include -> import here: b521658.

Also the check for cplusplus is missing?

I don't think that they are necessary in .mm file, right?

- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
{
[super finalizeUpdates:updateMask];
[_accessoryView.helper handleContentViewVisibilityForEnvironmentIfNeeded];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RCTViewComponentView changes view opacity via view.layer.opacity, e.g. in updateProps and invalidateLayer -> this overrides our setting. In order to bring back correct values, I update the visibility here.

Please document this in code. Because this is something you can not come up with by just reading the code.

Also document / create a ticket for the bug with devtools you described. Let's not let it go under radar.


That said, I can see that the RCTViewComponentView diffs the opacity before mutating it & it diffs it with prop values, not the value applied on the layer.

Image

Does this problem occur only in first render then? Or how does it work otherwise?

@kligarski kligarski requested a review from kkafar November 13, 2025 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants